forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import * as v from 'valibot'
2import { PackageRouteParamsSchema } from '#shared/schemas/package'
3import type {
4 PackageAnalysis,
5 ExtendedPackageJson,
6 TypesPackageInfo,
7 CreatePackageInfo,
8} from '#shared/utils/package-analysis'
9import {
10 analyzePackage,
11 getTypesPackageName,
12 getCreatePackageName,
13 hasBuiltInTypes,
14} from '#shared/utils/package-analysis'
15import {
16 getDevDependencySuggestion,
17 type DevDependencySuggestion,
18} from '#shared/utils/dev-dependency'
19import {
20 NPM_REGISTRY,
21 CACHE_MAX_AGE_ONE_DAY,
22 ERROR_PACKAGE_ANALYSIS_FAILED,
23} from '#shared/utils/constants'
24import { parseRepoUrl } from '#shared/utils/git-providers'
25import { encodePackageName } from '#shared/utils/npm'
26import { getLatestVersion, getLatestVersionBatch } from 'fast-npm-meta'
27
28interface AnalysisPackageJson extends ExtendedPackageJson {
29 readme?: string
30}
31
32export default defineCachedEventHandler(
33 async event => {
34 // Parse package name and optional version from path
35 // e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0"
36 const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
37
38 const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
39
40 try {
41 const { packageName, version } = v.parse(PackageRouteParamsSchema, {
42 packageName: rawPackageName,
43 version: rawVersion,
44 })
45
46 // Fetch package data
47 const encodedName = encodePackageName(packageName)
48 const versionSuffix = version ? `/${version}` : '/latest'
49 const pkg = await $fetch<AnalysisPackageJson>(
50 `${NPM_REGISTRY}/${encodedName}${versionSuffix}`,
51 )
52
53 // Only check for @types package if the package doesn't ship its own types
54 let typesPackage: TypesPackageInfo | undefined
55 if (!hasBuiltInTypes(pkg)) {
56 const typesPkgName = getTypesPackageName(packageName)
57 typesPackage = await fetchTypesPackageInfo(typesPkgName)
58 }
59
60 // Check for associated create-* package (e.g., vite -> create-vite, next -> create-next-app)
61 // Only show if the packages are actually associated (same maintainers or same org)
62 const createPackage = await findAssociatedCreatePackage(packageName, pkg)
63
64 const analysis = analyzePackage(pkg, { typesPackage, createPackage })
65 const devDependencySuggestion = getDevDependencySuggestion(packageName, pkg.readme)
66
67 return {
68 package: packageName,
69 version: pkg.version ?? version ?? 'latest',
70 devDependencySuggestion,
71 ...analysis,
72 } satisfies PackageAnalysisResponse
73 } catch (error: unknown) {
74 handleApiError(error, {
75 statusCode: 502,
76 message: ERROR_PACKAGE_ANALYSIS_FAILED,
77 })
78 }
79 },
80 {
81 maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes
82 swr: true,
83 getKey: event => {
84 const pkg = getRouterParam(event, 'pkg') ?? ''
85 return `analysis:v2:${pkg.replace(/\/+$/, '').trim()}`
86 },
87 },
88)
89
90/**
91 * Fetch @types package info including deprecation status using fast-npm-meta.
92 * Returns undefined if the package doesn't exist.
93 */
94async function fetchTypesPackageInfo(packageName: string): Promise<TypesPackageInfo | undefined> {
95 const result = await getLatestVersion(packageName, { metadata: true, throw: false })
96 if ('error' in result) {
97 return undefined
98 }
99 return {
100 packageName,
101 deprecated: result.deprecated,
102 }
103}
104
105/** Package metadata needed for association validation */
106interface PackageWithMeta {
107 maintainers?: Array<{ name: string }>
108 repository?: { url?: string }
109 deprecated?: string
110}
111
112/**
113 * Get all possible create-* package name patterns for a given package.
114 * e.g., "next" -> ["create-next", "create-next-app"]
115 * e.g., "@scope/foo" -> ["@scope/create-foo", "@scope/create-foo-app"]
116 */
117function getCreatePackageNameCandidates(packageName: string): string[] {
118 const baseName = getCreatePackageName(packageName)
119 return [baseName, `${baseName}-app`]
120}
121
122/**
123 * Find an associated create-* package by trying multiple naming patterns using batch API.
124 * Returns the first associated package found (preferring create-{name} over create-{name}-app).
125 */
126async function findAssociatedCreatePackage(
127 packageName: string,
128 basePkg: ExtendedPackageJson,
129): Promise<CreatePackageInfo | undefined> {
130 const candidates = getCreatePackageNameCandidates(packageName)
131
132 // Use batch API to fetch all candidates in a single request
133 const results = await getLatestVersionBatch(candidates, { metadata: true, throw: false })
134
135 // Process results in order (first valid match wins)
136 for (let i = 0; i < candidates.length; i++) {
137 const result = results[i]
138 const candidateName = candidates[i]
139 if (!result || !candidateName || 'error' in result) continue
140
141 // Need to fetch full package data for association validation (maintainers/repo)
142 const createPkgInfo = await fetchCreatePackageForValidation(
143 candidateName,
144 basePkg,
145 result.deprecated,
146 )
147 if (createPkgInfo) {
148 return createPkgInfo
149 }
150 }
151
152 return undefined
153}
154
155/**
156 * Fetch create-* package metadata for association validation.
157 * Returns CreatePackageInfo if the package is associated with the base package.
158 */
159async function fetchCreatePackageForValidation(
160 createPkgName: string,
161 basePkg: ExtendedPackageJson,
162 deprecated: string | undefined,
163): Promise<CreatePackageInfo | undefined> {
164 try {
165 const encodedName = encodePackageName(createPkgName)
166 // Fetch /latest to get maintainers and repository for association validation
167 const createPkg = await $fetch<PackageWithMeta>(`${NPM_REGISTRY}/${encodedName}/latest`)
168
169 // Validate that the packages are actually associated
170 if (!isAssociatedPackage(basePkg, createPkg)) {
171 return undefined
172 }
173
174 return {
175 packageName: createPkgName,
176 deprecated,
177 }
178 } catch {
179 return undefined
180 }
181}
182
183/**
184 * Check if two packages are associated (share maintainers or same repo owner).
185 */
186function isAssociatedPackage(
187 basePkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } },
188 createPkg: { maintainers?: Array<{ name: string }>; repository?: { url?: string } },
189): boolean {
190 const baseMaintainers = new Set(basePkg.maintainers?.map(m => m.name.toLowerCase()) ?? [])
191 const createMaintainers = createPkg.maintainers?.map(m => m.name.toLowerCase()) ?? []
192 const hasSharedMaintainer = createMaintainers.some(name => baseMaintainers.has(name))
193
194 return (
195 hasSharedMaintainer ||
196 hasSameRepositoryOwner(basePkg.repository?.url, createPkg.repository?.url)
197 )
198}
199
200/**
201 * Check if two repository URLs have the same owner (works with any git provider).
202 */
203function hasSameRepositoryOwner(
204 baseRepoUrl: string | undefined,
205 createRepoUrl: string | undefined,
206): boolean {
207 if (!baseRepoUrl || !createRepoUrl) return false
208
209 const baseRef = parseRepoUrl(baseRepoUrl)
210 const createRef = parseRepoUrl(createRepoUrl)
211
212 if (!baseRef || !createRef) return false
213 if (baseRef.provider !== createRef.provider) return false
214 if (baseRef.host && createRef.host && baseRef.host !== createRef.host) return false
215
216 return baseRef.owner.toLowerCase() === createRef.owner.toLowerCase()
217}
218
219export interface PackageAnalysisResponse extends PackageAnalysis {
220 package: string
221 version: string
222 devDependencySuggestion: DevDependencySuggestion
223}